/*
 * Copyright by LunaSec (owned by Refinery Labs, Inc)
 *
 * Licensed under the Business Source License v1.1
 * (the "License"); you may not use this file except in compliance with the
 * License. You may obtain a copy of the License at
 *
 * https://github.com/lunasec-io/lunasec/blob/master/licenses/BSL-LunaTrace.txt
 *
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 */
import { SeverityNamesOsv, severityOrderOsv } from '@lunatrace/lunatrace-common';
import semver from 'semver';

import {
  DependencyChain,
  FolderSettings,
  IgnoredVulnerability,
  ManifestNode,
  ManifestRelease,
  PackageVulnerability,
  TriagedPackageVulnerability,
  VulnerabilityGuide,
  VulnerableRelease,
} from './types';

export class VulnerabilityProcessor {
  private readonly ignoredVulnerabilities: IgnoredVulnerability[];
  private readonly minimumSeverity: SeverityNamesOsv;

  constructor(ignoredVulnerabilities: IgnoredVulnerability[], minimumSeverity: SeverityNamesOsv) {
    this.ignoredVulnerabilities = ignoredVulnerabilities;
    this.minimumSeverity = minimumSeverity;
  }

  public processVulnerabilitiesOnNode(node: ManifestNode, path: string): TriagedPackageVulnerability[] {
    const triagedVulnerabilities: TriagedPackageVulnerability[] = [];

    node.release.package.affected_by_vulnerability.forEach((vulnMeta) => {
      const vulnerableRange = convertRangesToSemverRange(vulnMeta.ranges);
      const isVulnerable = semver.satisfies(node.release.version, vulnerableRange);

      if (!isVulnerable) {
        return;
      }

      // filter by ignored
      const vulnId = vulnMeta.vulnerability.id;
      const ignored_vulnerability = this.ignoredVulnerabilities.find((ignored) => {
        return ignored.vulnerability_id === vulnId && ignored.locations.includes(path);
      });

      // Mark the vulns that can be trivially updated
      const triviallyUpdatableTo = precomputeVulnTriviallyUpdatableTo(node.range, vulnMeta);

      const beneathMinimumSeverity = VulnerabilityProcessor.severityNameBelowLimit(
        vulnMeta.vulnerability.severity_name,
        this.minimumSeverity
      );
      const triaged: TriagedPackageVulnerability = {
        ...vulnMeta,
        trivially_updatable_to: triviallyUpdatableTo,
        path,
        beneath_minimum_severity: beneathMinimumSeverity,
        fix_versions: computeFixVersions(vulnMeta),
        ignored: !!ignored_vulnerability,
        ignored_vulnerability: ignored_vulnerability,
      };
      triagedVulnerabilities.push(triaged);
    });
    return triagedVulnerabilities;
  }

  public static severityNameBelowLimit(
    vulnSeverity: string | undefined | null,
    minimumSeverity: SeverityNamesOsv
  ): boolean {
    if (minimumSeverity === 'Unknown') {
      return false;
    }

    if (!vulnSeverity) {
      return true;
    }
    const severityRank = severityOrderOsv.indexOf(vulnSeverity);
    const minimumSeverityRank = severityOrderOsv.indexOf(minimumSeverity);
    return severityRank < minimumSeverityRank;
  }
}

function computeFixVersions(vuln: PackageVulnerability): string[] {
  const fixedVersions: string[] = [];
  vuln.ranges.forEach((range) => {
    if (range.fixed) {
      fixedVersions.push(range.fixed);
    }
  });

  return fixedVersions;
}

function precomputeVulnTriviallyUpdatableTo(requestedRange: string, vuln: PackageVulnerability): string | null {
  const fixedVersions = computeFixVersions(vuln);
  const updatableToFixVersions = fixedVersions.filter((fixVersion) => {
    return semver.satisfies(fixVersion, requestedRange);
  });
  if (updatableToFixVersions.length === 0) {
    return null;
  }
  // Get the highest version we can take of the fixes that are possible
  const sortedUpdatableToFixVersions = semver.rsort(updatableToFixVersions);
  return sortedUpdatableToFixVersions[0];
}

function convertRangesToSemverRange(ranges: PackageVulnerability['ranges']): semver.Range {
  const vulnerableRanges: string[] = [];
  ranges.forEach((range) => {
    if (range.introduced && range.fixed) {
      vulnerableRanges.push(`>=${range.introduced} <${range.fixed}`);
    } else if (range.introduced) {
      vulnerableRanges.push(`>=${range.introduced}`);
    }
  });
  const vulnerableRangesString = vulnerableRanges.join(' || '); // Just put them all in one big range and let semver figure it out
  return new semver.Range(vulnerableRangesString);
}

type TriviallyUpdateableStatus = 'yes' | 'partially' | 'no';

export function isReleaseTriviallyUpdateable(
  triagedVulnerabilities: TriagedPackageVulnerability[]
): TriviallyUpdateableStatus {
  const triviallyUpdatebleVulnerabilities = triagedVulnerabilities.filter(
    (vulnMeta) => vulnMeta.trivially_updatable_to !== null
  );

  if (triviallyUpdatebleVulnerabilities.length === 0) {
    return 'no';
  }
  if (
    triviallyUpdatebleVulnerabilities.length > 0 &&
    triviallyUpdatebleVulnerabilities.length !== triagedVulnerabilities.length
  ) {
    return 'partially';
  }
  return 'yes';
}

function sortBySeverityName(nameOne: string | null | undefined, nameTwo: string | null | undefined) {
  return severityOrderOsv.indexOf(nameTwo || 'Unknown') - severityOrderOsv.indexOf(nameOne || 'Unknown');
}

export function createOrMergeVulnerablePackageRelease(
  existingRelease: VulnerableRelease | undefined,
  release: ManifestRelease,
  triagedVulnerability: TriagedPackageVulnerability,
  chains: DependencyChain[],
  devOnly: boolean,
  triviallyUpdateable: TriviallyUpdateableStatus
): VulnerableRelease {
  const rawSeverity = triagedVulnerability.vulnerability.severity_name;
  // Clean the severity just in case of bad data or nulls, just in case
  const severity = rawSeverity && severityOrderOsv.includes(rawSeverity) ? rawSeverity : 'Unknown';

  const guidesFromVuln = triagedVulnerability.vulnerability.guide_vulnerabilities.map((gv) => gv.guide);

  // We aren't tracking this release yet, make a new object
  if (!existingRelease) {
    return {
      paths: [triagedVulnerability.path],
      release,
      severity,
      cvss: triagedVulnerability.vulnerability.cvss_score || null,
      dev_only: devOnly,
      chains,
      trivially_updatable: triviallyUpdateable,
      affected_by: [triagedVulnerability],
      beneath_minimum_severity: triagedVulnerability.beneath_minimum_severity,
      guides: guidesFromVuln,
      fix_versions: triagedVulnerability.fix_versions,
    };
  }
  // We are already tracking this release, just merge the new information
  return mergeExistingRelease(
    triagedVulnerability,
    existingRelease,
    devOnly,
    severity,
    guidesFromVuln,
    chains,
    triviallyUpdateable
  );
}

function mergeExistingRelease(
  triagedVulnerability: TriagedPackageVulnerability,
  existingRelease: VulnerableRelease,
  devOnly: boolean,
  severity: string,
  guidesFromVuln: VulnerabilityGuide[],
  chains: DependencyChain[],
  triviallyUpdateable: TriviallyUpdateableStatus
) {
  // devOnly will be false if ANY nodes aren't devOnly
  existingRelease.dev_only = devOnly && existingRelease.dev_only;

  // take the highest cvss
  if (
    triagedVulnerability.vulnerability.cvss_score &&
    (existingRelease.cvss || 0) < triagedVulnerability.vulnerability.cvss_score
  ) {
    existingRelease.cvss = triagedVulnerability.vulnerability.cvss_score;
  }
  // take the highest severity
  if (severityOrderOsv.indexOf(severity) > severityOrderOsv.indexOf(existingRelease.severity)) {
    existingRelease.severity = severity;
  }
  // add vuln to the release if it's not being tracked yet
  if (!existingRelease.affected_by.some((v) => v.vulnerability.id === triagedVulnerability.vulnerability.id)) {
    existingRelease.affected_by.push(triagedVulnerability);
    existingRelease.affected_by.sort((v1, v2) => {
      return sortBySeverityName(v1.vulnerability.severity_name, v2.vulnerability.severity_name);
    });
  }

  if (!existingRelease.paths.includes(triagedVulnerability.path)) {
    existingRelease.paths.push(triagedVulnerability.path);
  }

  // Update severity check, if the new severity is too high for the threshold, it will bump the release to not beneath minimum severity.
  existingRelease.beneath_minimum_severity =
    existingRelease.beneath_minimum_severity && triagedVulnerability.beneath_minimum_severity;

  // Add guides, excluding dupes
  guidesFromVuln.forEach((newGuide) => {
    const guideAlreadyAdded = existingRelease.guides.some((existingGuide) => {
      return existingGuide.id === newGuide.id;
    });
    if (!guideAlreadyAdded) {
      existingRelease.guides.push(newGuide);
    }
  });

  // merge fix versions that are common between vulns
  existingRelease.fix_versions = Array.from(
    new Set([...existingRelease.fix_versions, ...triagedVulnerability.fix_versions])
  );

  existingRelease.trivially_updatable = mergeUpdateableStatus(existingRelease.trivially_updatable, triviallyUpdateable);

  existingRelease.chains = existingRelease.chains.concat(chains);

  return existingRelease;
}

function mergeUpdateableStatus(
  existingStatus: TriviallyUpdateableStatus,
  newStatus: TriviallyUpdateableStatus
): TriviallyUpdateableStatus {
  // Bit of tricky logic to merge these statuses.
  // we only have to look at cases where the status would change and in those cases always return "partially".
  // otherwise we simply return the existing status
  if (existingStatus === 'yes') {
    if (newStatus === 'partially' || newStatus === 'no') {
      return 'partially';
    }
  }
  if (existingStatus === 'no') {
    if (newStatus === 'partially' || newStatus === 'yes') {
      return 'partially';
    }
  }

  return existingStatus;
}
